-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(browser): Detect redirects when emitting navigation spans #16324
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
size-limit report 📦
|
❌ Unsupported file format
|
ccbd697
to
eb3c0bc
Compare
eb3c0bc
to
cb8e92e
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense to me! There aren't many product areas in performance that specifically rely on navigations so I think this should be fine (and I think we'd consider surfacing redirects in those areas a bug anyways).
@@ -371,6 +396,10 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio | |||
startTrackingInteractions(); | |||
} | |||
|
|||
if (detectRedirects && optionalWindowDocument) { | |||
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
are there other events, such as key presses, that could indicate a user manually navigating?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, keypress might also be a good candidate, agreed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 also looking at keypress
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry for the late review, but LGTM! I think we probably need to widen the timespan a bit because 300ms feel a bit fast to me (thinking of the endless redirects I get when doing SSO or stuff like this). But maybe it's good enough for now. I'd say its something we adjust on a per-feedback basis.
@@ -371,6 +396,10 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio | |||
startTrackingInteractions(); | |||
} | |||
|
|||
if (detectRedirects && optionalWindowDocument) { | |||
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, keypress might also be a good candidate, agreed.
19d02d3
to
67791e9
Compare
67791e9
to
e2018b5
Compare
e2018b5
to
9dec9c3
Compare
9dec9c3
to
da0cffe
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Navigation URL Metadata Update Fails
The scope.setSDKProcessingMetadata
is not updated for navigation spans if the URL is falsy or if the navigation is detected as a redirect. This prevents subsequent events from having the correct URL information on the scope. Additionally, the redirect detection logic uses inconsistent timestamp functions (timestampInSeconds
vs dateTimestampInSeconds
), which can lead to inaccurate timing comparisons.
packages/browser/src/tracing/browserTracingIntegration.ts#L469-L780
sentry-javascript/packages/browser/src/tracing/browserTracingIntegration.ts
Lines 469 to 780 in e82bf79
const interactionHandler = (): void => { | |
lastInteractionTimestamp = timestampInSeconds(); | |
}; | |
addEventListener('click', interactionHandler, { capture: true }); | |
addEventListener('keydown', interactionHandler, { capture: true, passive: true }); | |
} | |
function maybeEndActiveSpan(): void { | |
const activeSpan = getActiveIdleSpan(client); | |
if (activeSpan && !spanToJSON(activeSpan).timestamp) { | |
DEBUG_BUILD && logger.log(`[Tracing] Finishing current active span with op: ${spanToJSON(activeSpan).op}`); | |
// If there's an open active span, we need to finish it before creating an new one. | |
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, 'cancelled'); | |
activeSpan.end(); | |
} | |
} | |
client.on('startNavigationSpan', (startSpanOptions, navigationOptions) => { | |
if (getClient() !== client) { | |
return; | |
} | |
if (navigationOptions?.isRedirect) { | |
DEBUG_BUILD && | |
logger.warn('[Tracing] Detected redirect, navigation span will not be the root span, but a child span.'); | |
_createRouteSpan( | |
client, | |
{ | |
op: 'navigation.redirect', | |
...startSpanOptions, | |
}, | |
false, | |
); | |
return; | |
} | |
maybeEndActiveSpan(); | |
getIsolationScope().setPropagationContext({ traceId: generateTraceId(), sampleRand: Math.random() }); | |
const scope = getCurrentScope(); | |
scope.setPropagationContext({ traceId: generateTraceId(), sampleRand: Math.random() }); | |
// We reset this to ensure we do not have lingering incorrect data here | |
// places that call this hook may set this where appropriate - else, the URL at span sending time is used | |
scope.setSDKProcessingMetadata({ | |
normalizedRequest: undefined, | |
}); | |
_createRouteSpan(client, { | |
op: 'navigation', | |
...startSpanOptions, | |
}); | |
}); | |
client.on('startPageLoadSpan', (startSpanOptions, traceOptions = {}) => { | |
if (getClient() !== client) { | |
return; | |
} | |
maybeEndActiveSpan(); | |
const sentryTrace = traceOptions.sentryTrace || getMetaContent('sentry-trace'); | |
const baggage = traceOptions.baggage || getMetaContent('baggage'); | |
const propagationContext = propagationContextFromHeaders(sentryTrace, baggage); | |
const scope = getCurrentScope(); | |
scope.setPropagationContext(propagationContext); | |
// We store the normalized request data on the scope, so we get the request data at time of span creation | |
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL | |
scope.setSDKProcessingMetadata({ | |
normalizedRequest: getHttpRequestData(), | |
}); | |
_createRouteSpan(client, { | |
op: 'pageload', | |
...startSpanOptions, | |
}); | |
}); | |
}, | |
afterAllSetup(client) { | |
let startingUrl: string | undefined = getLocationHref(); | |
if (linkPreviousTrace !== 'off') { | |
linkTraces(client, { linkPreviousTrace, consistentTraceSampling }); | |
} | |
if (WINDOW.location) { | |
if (instrumentPageLoad) { | |
const origin = browserPerformanceTimeOrigin(); | |
startBrowserTracingPageLoadSpan(client, { | |
name: WINDOW.location.pathname, | |
// pageload should always start at timeOrigin (and needs to be in s, not ms) | |
startTime: origin ? origin / 1000 : undefined, | |
attributes: { | |
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', | |
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser', | |
}, | |
}); | |
} | |
if (instrumentNavigation) { | |
addHistoryInstrumentationHandler(({ to, from }) => { | |
/** | |
* This early return is there to account for some cases where a navigation transaction starts right after | |
* long-running pageload. We make sure that if `from` is undefined and a valid `startingURL` exists, we don't | |
* create an uneccessary navigation transaction. | |
* | |
* This was hard to duplicate, but this behavior stopped as soon as this fix was applied. This issue might also | |
* only be caused in certain development environments where the usage of a hot module reloader is causing | |
* errors. | |
*/ | |
if (from === undefined && startingUrl?.indexOf(to) !== -1) { | |
startingUrl = undefined; | |
return; | |
} | |
startingUrl = undefined; | |
const parsed = parseStringToURLObject(to); | |
const activeSpan = getActiveIdleSpan(client); | |
const navigationIsRedirect = | |
activeSpan && detectRedirects && isRedirect(activeSpan, lastInteractionTimestamp); | |
startBrowserTracingNavigationSpan( | |
client, | |
{ | |
name: parsed?.pathname || WINDOW.location.pathname, | |
attributes: { | |
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', | |
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser', | |
}, | |
}, | |
{ url: to, isRedirect: navigationIsRedirect }, | |
); | |
}); | |
} | |
} | |
if (markBackgroundSpan) { | |
registerBackgroundTabDetection(); | |
} | |
if (enableInteractions) { | |
registerInteractionListener(client, idleTimeout, finalTimeout, childSpanTimeout, latestRoute); | |
} | |
if (enableInp) { | |
registerInpInteractionListener(); | |
} | |
instrumentOutgoingRequests(client, { | |
traceFetch, | |
traceXHR, | |
trackFetchStreamPerformance, | |
tracePropagationTargets: client.getOptions().tracePropagationTargets, | |
shouldCreateSpanForRequest, | |
enableHTTPTimings, | |
onRequestSpanStart, | |
}); | |
}, | |
}; | |
}) satisfies IntegrationFn; | |
/** | |
* Manually start a page load span. | |
* This will only do something if a browser tracing integration integration has been setup. | |
* | |
* If you provide a custom `traceOptions` object, it will be used to continue the trace | |
* instead of the default behavior, which is to look it up on the <meta> tags. | |
*/ | |
export function startBrowserTracingPageLoadSpan( | |
client: Client, | |
spanOptions: StartSpanOptions, | |
traceOptions?: { sentryTrace?: string | undefined; baggage?: string | undefined }, | |
): Span | undefined { | |
client.emit('startPageLoadSpan', spanOptions, traceOptions); | |
getCurrentScope().setTransactionName(spanOptions.name); | |
return getActiveIdleSpan(client); | |
} | |
/** | |
* Manually start a navigation span. | |
* This will only do something if a browser tracing integration has been setup. | |
*/ | |
export function startBrowserTracingNavigationSpan( | |
client: Client, | |
spanOptions: StartSpanOptions, | |
options?: { url?: string; isRedirect?: boolean }, | |
): Span | undefined { | |
const { url, isRedirect } = options || {}; | |
client.emit('startNavigationSpan', spanOptions, { isRedirect }); | |
const scope = getCurrentScope(); | |
scope.setTransactionName(spanOptions.name); | |
// We store the normalized request data on the scope, so we get the request data at time of span creation | |
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL | |
if (url && !isRedirect) { | |
scope.setSDKProcessingMetadata({ | |
normalizedRequest: { | |
...getHttpRequestData(), | |
url, | |
}, | |
}); | |
} | |
return getActiveIdleSpan(client); | |
} | |
/** Returns the value of a meta tag */ | |
export function getMetaContent(metaName: string): string | undefined { | |
/** | |
* This is just a small wrapper that makes `document` optional. | |
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up. | |
*/ | |
const optionalWindowDocument = WINDOW.document as (typeof WINDOW)['document'] | undefined; | |
const metaTag = optionalWindowDocument?.querySelector(`meta[name=${metaName}]`); | |
return metaTag?.getAttribute('content') || undefined; | |
} | |
/** Start listener for interaction transactions */ | |
function registerInteractionListener( | |
client: Client, | |
idleTimeout: BrowserTracingOptions['idleTimeout'], | |
finalTimeout: BrowserTracingOptions['finalTimeout'], | |
childSpanTimeout: BrowserTracingOptions['childSpanTimeout'], | |
latestRoute: RouteInfo, | |
): void { | |
/** | |
* This is just a small wrapper that makes `document` optional. | |
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up. | |
*/ | |
const optionalWindowDocument = WINDOW.document as (typeof WINDOW)['document'] | undefined; | |
let inflightInteractionSpan: Span | undefined; | |
const registerInteractionTransaction = (): void => { | |
const op = 'ui.action.click'; | |
const activeIdleSpan = getActiveIdleSpan(client); | |
if (activeIdleSpan) { | |
const currentRootSpanOp = spanToJSON(activeIdleSpan).op; | |
if (['navigation', 'pageload'].includes(currentRootSpanOp as string)) { | |
DEBUG_BUILD && | |
logger.warn(`[Tracing] Did not create ${op} span because a pageload or navigation span is in progress.`); | |
return undefined; | |
} | |
} | |
if (inflightInteractionSpan) { | |
inflightInteractionSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, 'interactionInterrupted'); | |
inflightInteractionSpan.end(); | |
inflightInteractionSpan = undefined; | |
} | |
if (!latestRoute.name) { | |
DEBUG_BUILD && logger.warn(`[Tracing] Did not create ${op} transaction because _latestRouteName is missing.`); | |
return undefined; | |
} | |
inflightInteractionSpan = startIdleSpan( | |
{ | |
name: latestRoute.name, | |
op, | |
attributes: { | |
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRoute.source || 'url', | |
}, | |
}, | |
{ | |
idleTimeout, | |
finalTimeout, | |
childSpanTimeout, | |
}, | |
); | |
}; | |
if (optionalWindowDocument) { | |
addEventListener('click', registerInteractionTransaction, { capture: true }); | |
} | |
} | |
// We store the active idle span on the client object, so we can access it from exported functions | |
const ACTIVE_IDLE_SPAN_PROPERTY = '_sentry_idleSpan'; | |
function getActiveIdleSpan(client: Client): Span | undefined { | |
return (client as { [ACTIVE_IDLE_SPAN_PROPERTY]?: Span })[ACTIVE_IDLE_SPAN_PROPERTY]; | |
} | |
function setActiveIdleSpan(client: Client, span: Span | undefined): void { | |
addNonEnumerableProperty(client, ACTIVE_IDLE_SPAN_PROPERTY, span); | |
} | |
// The max. time in seconds between two pageload/navigation spans that makes us consider the second one a redirect | |
const REDIRECT_THRESHOLD = 0.3; | |
function isRedirect(activeSpan: Span, lastInteractionTimestamp: number | undefined): boolean { | |
const spanData = spanToJSON(activeSpan); | |
const now = dateTimestampInSeconds(); | |
// More than 300ms since last navigation/pageload span? | |
// --> never consider this a redirect | |
const startTimestamp = spanData.start_timestamp; | |
if (now - startTimestamp > REDIRECT_THRESHOLD) { | |
return false; | |
} | |
// A click happened in the last 300ms? | |
// --> never consider this a redirect | |
if (lastInteractionTimestamp && now - lastInteractionTimestamp <= REDIRECT_THRESHOLD) { | |
return false; |
Bug: Browser Tracing Integration Event Listener Leak
The browserTracingIntegration
introduces a memory leak by adding global click
and keydown
event listeners for redirect detection without ever removing them. This causes listeners to accumulate when the integration is reinitialized or multiple instances are created, such as in SPAs, hot module reloading, or test environments. A cleanup mechanism is required to prevent this accumulation.
packages/browser/src/tracing/browserTracingIntegration.ts#L467-L475
sentry-javascript/packages/browser/src/tracing/browserTracingIntegration.ts
Lines 467 to 475 in e82bf79
if (detectRedirects && optionalWindowDocument) { | |
const interactionHandler = (): void => { | |
lastInteractionTimestamp = timestampInSeconds(); | |
}; | |
addEventListener('click', interactionHandler, { capture: true }); | |
addEventListener('keydown', interactionHandler, { capture: true, passive: true }); | |
} | |
Was this report helpful? Give feedback by reacting with 👍 or 👎
Closes #15286
This PR adds a new option to
browserTracingIntegration
,detectRedirects
, which is enabled by default. If this is enabled, the integration will try to detect if a navigation is actually a redirect based on a simple heuristic, and in this case, will not end the ongoing pageload/navigation, but instead let it run and create anavigation.redirect
zero-duration span instead.An example trace for this would be: https://sentry-sdks.sentry.io/explore/discover/trace/95280de69dc844448d39de7458eab527/?dataset=transactions&eventId=8a1150fd1dc846e4ac8420ccf03ad0ee&field=title&field=project&field=user.display&field=timestamp&name=All%20Errors&project=4504956726345728&query=&queryDataset=transaction-like&sort=-timestamp&source=discover&statsPeriod=5m×tamp=1747646096&yAxis=count%28%29

Where the respective index route that triggered this has this code:
The used heuristic is:
this limit was chosen somewhat arbitrarily, open for other suggestions too.
While this logic will not be 100% bullet proof, it should be reliable enough and likely better than what we have today. Users can opt-out of this logic via
browserTracingIntegration({ detectRedirects: false })
, if needed.